Skip to content

feat(windows): wrap .wolf hook commands in wscript+VBS to hide console flash - #42

Closed
mann1x wants to merge 1 commit into
cytostack:mainfrom
mann1x:feature/windows-wscript-hooks
Closed

mann1x wants to merge 1 commit into
cytostack:mainfrom
mann1x:feature/windows-wscript-hooks

Conversation

@mann1x

@mann1x mann1x commented May 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On Windows, every .claude/settings.json hook that OpenWolf installs
(SessionStart, PreToolUse × 2, PostToolUse × 2, Stop — six
per project) currently uses the bare form:

"command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js\""

When Claude Code fires the hook, it spawns this without
windowsHide: true. node.exe is a console-subsystem binary, so
Windows allocates a fresh console window for every hook fire —
visible as a brief black flash. Under active editor use this stacks
to multiple flashes per second (every Edit/Write triggers
PreToolUse + PostToolUse). It steals focus from the editor and is
unusable in practice.

Anthropic acknowledged the upstream gap
(anthropics/claude-code#19012)
and closed it as not planned. The standard workaround — adopted
this PR — is a tiny VBS wrapper invoked via wscript.exe.
wscript.exe is a windows-subsystem host so the OS never allocates
a console for it; WScript.Shell.Run(cmd, 0, True) then launches
the underlying node "..." with SW_HIDE and waits for completion,
propagating the exit code so the Claude Code hook contract is
preserved.

The fix

A single new helper buildHookCommand(scriptName) (in
src/utils/hook-command.ts) decides per-platform whether to wrap:

// POSIX or no VBS asset present:
node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"

// Windows + VBS asset present:
wscript //nologo "<install>/dist/assets/hook-runner.vbs" node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"

Both init.ts and update.ts route their six hook-command strings
through this helper, so a openwolf init or openwolf update after
this lands rewrites existing projects' .claude/settings.json to
the wscript-wrapped form (replaceOpenWolfHooks() already filters by
.wolf/hooks/ substring so the swap is in-place and idempotent).

Cross-platform safety

  • POSIX hosts: byte-identical to today. buildHookCommand
    returns the existing node "..." shape when
    process.platform !== 'win32'.
  • Windows installs missing the VBS asset (older OpenWolf dist):
    same fallback — the helper returns the bare node "..." shape
    if findHookRunnerVbs() can't locate the asset.
  • Existing projects on a fresh OpenWolf install: the next
    openwolf init / openwolf update swap; until then they keep
    flashing as today (no regression).

Files touched

 assets/hook-runner.vbs            |  38 +++++++  (new)
 scripts/copy-hook-runner.mjs      |  20 +++++  (new — build step)
 src/utils/hook-command.ts         |  62 +++++++++  (new — helper)
 src/cli/init.ts                   |  44 +++-----  (route through helper)
 src/cli/update.ts                 |  41 +++-----  (route through helper)
 package.json                      |   2 +-      (build appends the copy step)
 6 files changed, 195 insertions(+), 94 deletions(-)

assets/hook-runner.vbs

If WScript.Arguments.Count < 1 Then WScript.Quit(1)
Dim cmd, i : cmd = ""
For i = 0 To WScript.Arguments.Count - 1
  If i > 0 Then cmd = cmd & " "
  cmd = cmd & """" & WScript.Arguments(i) & """"
Next
Set sh = CreateObject("WScript.Shell")
WScript.Quit(sh.Run(cmd, 0, True))
  • 0 — SW_HIDE: never show a window
  • True — wait for completion, propagate exit code
  • Args are re-quoted so paths with spaces (C:\Program Files\nodejs\node.exe)
    survive the round-trip through WScript.Arguments → cmd string

scripts/copy-hook-runner.mjs

Tiny ESM build step that copies assets/hook-runner.vbs →
dist/assets/hook-runner.vbs next to the compiled CLI. Already in
the dist/ glob shipped via package.json files so npm pack
picks it up without further configuration.

src/utils/hook-command.ts

Two exports:

  • findHookRunnerVbs(): string | null — resolves the VBS path at
    runtime relative to the compiled module location (dist/src/utils/
    → dist/assets/)
  • buildHookCommand(scriptName, platform?, vbsPath?) — emits the
    per-platform command string; both platform and vbsPath are
    injectable for tests

Verification

  • Build: tsc clean; node scripts/copy-hook-runner.mjs copies
    the VBS into dist/assets/; npm pack ships it inside the
    tarball under the dist/ glob
  • Smoke (Linux): buildHookCommand('post-write.js', 'linux')
    returns the unchanged node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"
  • Smoke (Windows 10 22H2 / pandorum): buildHookCommand('post-write.js')
    on the deployed install returns
    wscript //nologo "C:/Users/manni/AppData/Roaming/npm/node_modules/openwolf/dist/assets/hook-runner.vbs" node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"
  • End-to-end: ran openwolf init on three real Windows projects;
    .claude/settings.json now contains the wscript-wrapped form for
    all six hooks per project; zero console flashes during an
    extended Claude Code session, operator-confirmed
  • Companion PR: fix(windows): eliminate console-window flashes across all three sources (spawn audit + cmd-shim bypass + wscript wrapper) caliber-ai-org/ai-setup#222 ships the same VBS
    wrapper shape for Caliber's hook commands; the combined deploy on
    pandorum eliminates every Windows-flash source observed during
    the audit

Compatibility

  • replaceOpenWolfHooks() matches on .wolf/hooks/ substring so
    it identifies both the old bare-node and new wscript-wrapped
    shapes as OpenWolf-owned and rewrites in place — no stale
    duplicates after upgrade
  • User-authored hooks (any entry whose command doesn't contain
    .wolf/hooks/) are preserved untouched, same as today
  • Tests: existing init/update tests already exercise the
    replaceOpenWolfHooks path — they continue to pass because the
    helper is platform-gated and they run on Linux

Status

Ready for review. The change is empirically validated end-to-end on
Windows 10 22H2 (pandorum, three real OpenWolf-managed projects,
extended Claude Code session — zero flashes from .wolf/ hooks).
The companion Caliber PR
(caliber-ai-org/ai-setup#222)
ships the same VBS-wrapper shape for Caliber's hook commands and
has CI green across the 6-cell Linux/Windows × Node 20/22/25
matrix; combined deploy was the configuration that produced the
zero-flash result.

…e flash

On Windows, Claude Code spawns each .claude/settings.json hook command via
its parent shell. The node.exe used by the bare `node "..."` form is a
console-subsystem binary, so even with CREATE_NO_WINDOW set by Claude Code's
spawn, a brief black console window flashes onscreen for every PostToolUse,
SessionStart, etc. fire — repeatedly throughout a session.

Wrap the command in `wscript //nologo "<vbs>" node "..."`. `wscript.exe` is
a windows-subsystem host, so the OS never allocates a console for it;
WScript.Shell.Run(cmd, 0, True) then invokes the underlying `node "..."`
with SW_HIDE and waits for completion, propagating the exit code so Claude
Code's hook contract is preserved.

POSIX hosts get the unchanged bare form — buildHookCommand falls back to
`node "$CLAUDE_PROJECT_DIR/.wolf/hooks/<x>.js"` when platform !== 'win32'
or the VBS asset isn't found.

Changes:
- assets/hook-runner.vbs — universal SW_HIDE wrapper (re-quotes args)
- scripts/copy-hook-runner.mjs — build step copies VBS to dist/assets/
- src/utils/hook-command.ts — buildHookCommand() + findHookRunnerVbs()
- src/cli/init.ts, src/cli/update.ts — call buildHookCommand() per hook
- package.json — build appends `node scripts/copy-hook-runner.mjs`

Existing projects need an `openwolf init` (or `openwolf update`) re-run for
the new command form to land in their .claude/settings.json; the
replaceOpenWolfHooks() matcher (".wolf/hooks/" substring) handles the
upgrade in place without touching user-authored hooks.

Companion to the Caliber wscript+VBS wrapper landed in
caliber-ai-org/ai-setup#fix/windows-hide-spawn — same approach, same VBS
shape, validated empirically on pandorum where zero flashes remained after
both wrappers were active.
@mann1x
mann1x marked this pull request as ready for review May 23, 2026 13:31
@cytostack cytostack self-assigned this May 26, 2026
@cytostack

Copy link
Copy Markdown
Owner

Thanks @mann1x and @Mizuho0329. Your Windows wrapper proposal is credited; it has not been included, and console-flash validation remains open.

@mann1x

mann1x commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Closing this — and the validation you asked for is the reason. It says the wrapper in this PR cannot ship, and it also says the goal is achievable by other means. Both halves are measured, on two independent hosts.

The wrapper deadlocks every hook

hook-runner.vbs is built on WScript.Shell.Run, which gives the child a fresh console instead of the parent's pipes. Claude Code delivers the hook payload on stdin and reads the reply from stdout — as the comment at the top of src/cli/hook-manifest.ts notes for a related reason — so node waits forever on a stdin that never reaches EOF.

Measured identically on GitHub windows-latest (Server 2025) and on a Windows 10 Pro desktop:

hook ran stdout exit code
node "<hook>.js" (today's form) yes ECHO:PAYLOAD_OK 7
wscript //nologo hook-runner.vbs node "<hook>.js" no none none — killed at the 20 s cap

So it does not merely lose output: it hangs until Claude Code's per-hook timeout, on every tool call. That is worse than the flash it removes, and it is why this should not be merged.

To be fair to the approach, the hiding part works. The same VBS, changed only from SW_HIDE to SW_SHOWNORMAL, showed 1 window against 0. The defect is exclusively in the stdio path.

What actually causes the flash

Not running node — console allocation. A parent process with no console of its own forces a console-subsystem child to allocate one, and that console is visible. Where the parent already owns a console, the child attaches to it and nothing appears.

Measured on the Win10 desktop, interactive session:

spawn shape visible consoles
node given its own console (Start-Process) 1
console-less parent → node, redirected stdio 1
same, parent started via WSH so it provably has no console 1
node, redirected stdio, parent owns a console 0
bash -c 'node "<hook>.js"' — how a settings.json command is run 0

That last row is worth pausing on before anyone spends effort here: in the shape Claude Code actually uses, from a terminal, there is no flash to fix. It appears when whatever spawns the hook has no console of its own — a GUI-launched parent, an extension host, a service. So this affects some users' setups and not others, which probably explains why it has been hard to pin down.

A launcher that does work

The requirement is not "hide a window". It is: allocate no visible console, inherit the parent's std handles rather than replace them, and propagate the exit code. That means CreateProcess from a /target:winexe binary with both CREATE_NO_WINDOW and STARTF_USESTDHANDLES set to the parent's own GetStdHandle values, plus bInheritHandles.

Both halves are load-bearing. My first attempt used .NET's ProcessStartInfo with CreateNoWindow = true and no redirection, on the assumption that "no redirection" means "inherit the parent's handles". It hung exactly like the VBS — .NET sets STARTF_USESTDHANDLES only when it is redirecting, so CREATE_NO_WINDOW allocated a console and the child took its handles. Same defect, different costume.

With both flags, on the same desktop session as the table above:

visible consoles hook ran stdout exit
console-less parent → node 1 yes ECHO:PAYLOAD_OK 7
console-less parent → launcher → node 0 yes ECHO:PAYLOAD_OK 7

Flash gone, hook contract intact.

The honest cost, and the options

This needs a native binary — roughly 4 KB — which is a real change in what an npm package contains, and you may reasonably decline on that alone. The options, with what each actually costs:

  1. Ship a prebuilt .exe. Simplest at runtime. Costs: an unreviewable binary in a JS package, antivirus false positives on an unsigned launcher that spawns processes, code-signing expectations, and a per-architecture story for arm64.
  2. Compile at install time with the csc.exe that ships in C:\Windows\Microsoft.NET\Framework64\ — present on every Windows, no SDK. No binary in the repo or the tarball, and the source is reviewable. Costs: it can be blocked by lockdown policy or AV, npm --ignore-scripts skips postinstall entirely, and it needs a fallback path when compilation fails. Doing it from openwolf init/update rather than postinstall avoids the --ignore-scripts problem.
  3. Fall back cleanly. Whichever of the above, buildHookCommand should emit today's bare node "<script>" whenever the launcher is absent, so a failed build or a blocked install degrades to current behaviour rather than to a broken hook. That is how I would gate it.
  4. Do nothing here, and report it upstream instead. The spawner is the one that controls the creation flags, and it is the only party that knows whether it has a console. A CREATE_NO_WINDOW in Claude Code's hook spawn fixes this for every hook of every tool, with no binary anywhere. Given the bash -c row above, this is arguably the correct place for the fix, and it costs openwolf nothing.

My honest read is that (4) is the right first move and (2)+(3) is the only version of a local fix I would argue for. I am happy to open a separate PR for (2)+(3) if you want it, and equally happy for the answer to be no — the measurement is the part worth having either way.

Both scripts are self-contained and on fix/windows-console-flash-validated: scripts/validate-console-flash.ps1 characterises the spawn shapes, scripts/study-gui-launcher.ps1 builds the launcher and tests it. They must run from an interactive desktop session and refuse to draw a conclusion in a headless one, rather than reporting "no window appeared" — which is what a blind harness would otherwise call success. Each has a control that must raise a window before any invisibility claim is accepted; both of my own calibration mistakes during this work surfaced that way instead of as a wrong answer.

🤖 Generated with Claude Code

@mann1x

mann1x commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Closing per the measurement above: the wrapper as written hangs every hook, so there is nothing here to merge. The launcher alternative is a separate proposal if you want it — no obligation, and the upstream option costs you nothing.

@mann1x mann1x closed this Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants